fix: the host ignored $CLAUDE_CONFIG_DIR and read ~/.claude regardless - #570
Conversation
Claude Code reads $CLAUDE_CONFIG_DIR before ~/.claude, and the probe dl runs inside a container has always read it too, because a devcontainer feature may set it. Only the host side did not: it joined a hardcoded .claude/.credentials.json onto $HOME, so a host that had moved its configuration reported NoToken::NotLoggedIn while holding a perfectly good login. The symptom was silent, which is why it survived. "No credential file at ~/.claude" is also the ordinary macOS state, where the login lives in the keychain, so it is deliberately not warned about; a host that had moved its config got that same quiet answer and no way to tell the two apart. The variable replaces the default rather than being tried ahead of it. Claude Code does not fall back from $CLAUDE_CONFIG_DIR to ~/.claude, and a fallback here would forward a credential out of a directory Claude Code is not reading, which is the same defect one level down: invisible until the host holds two logins, at which point it forwards the wrong one. An empty value counts as unset, matching domain::xdg's rule and what a shell exporting a bare variable means. Order is unchanged above the new arm: DEVLAUNCH_NO_CLAUDE_TOKEN first, then any exported CLAUDE_CODE_OAUTH_TOKEN, and only then the file. The exported token stays above the variable because both are ambient and that hatch is what lets a dl running inside a workspace forward the token it was handed. CREDENTIALS_RELPATH splits into CONFIG_RELPATH and CREDENTIALS_FILENAME, so the directory half is a decision and the filename half is a constant. CONFIG_RELPATH is deliberately not shared with flows::provision's CLAUDE_CONFIG_RELPATH: the same string about two different machines, with a comment in each naming the other. Public-API snapshots are untouched, since every item involved is pub(crate).
There was a problem hiding this comment.
Sorry @JSmithRobotics, you've used your own review budget of 250,000 diff characters for the last 7 days.
You can request another review in 7 days by commenting @sourcery-ai review. Upgrade to get a review now.
Reviewer's GuideThe PR fixes host-side Claude authentication by selecting credentials from Sequence diagram for forwarding a host Claude loginsequenceDiagram
participant User
participant DL
participant Resolver
participant ConfigDir
participant WorkspaceClaude
User->>DL: Open workspace
DL->>Resolver: resolve_token(home, host)
alt DEVLAUNCH_NO_CLAUDE_TOKEN
Resolver-->>DL: NoToken::OptedOut
else Exported CLAUDE_CODE_OAUTH_TOKEN
Resolver-->>DL: Exported access token
else Non-empty CLAUDE_CONFIG_DIR
Resolver->>ConfigDir: Read .credentials.json
ConfigDir-->>Resolver: accessToken
Resolver-->>DL: Access token
DL->>WorkspaceClaude: Forward CLAUDE_CODE_OAUTH_TOKEN
else Empty or unset CLAUDE_CONFIG_DIR
Resolver->>Resolver: Read HOME/.claude/.credentials.json
Resolver-->>DL: Access token or NoToken::NotLoggedIn
DL->>WorkspaceClaude: Forward CLAUDE_CODE_OAUTH_TOKEN
end
Flow diagram for host Claude credential resolutionflowchart TD
Start["Resolve Claude token"] --> OptOut{DEVLAUNCH_NO_CLAUDE_TOKEN set}
OptOut -->|Yes| NoToken["Missing: opted out"]
OptOut -->|No| Exported{CLAUDE_CODE_OAUTH_TOKEN valid}
Exported -->|Yes| Forward["Forward exported token"]
Exported -->|No| ConfigDir{CLAUDE_CONFIG_DIR non-empty}
ConfigDir -->|Yes| Moved["Read CONFIG_DIR/.credentials.json"]
ConfigDir -->|No| Home["Read HOME/.claude/.credentials.json"]
Moved --> Result["Forward access token or report not logged in"]
Home --> Result
File-Level Changes
Tips and commandsInteracting with Sourcery
Customizing Your ExperienceAccess your dashboard to:
Getting Help
|
blooop
left a comment
There was a problem hiding this comment.
Closes a real asymmetry, and the direction is right: the container probe has always read $CLAUDE_CONFIG_DIR and the host side did not. Replacing rather than falling back matches Claude Code, and the reasoning for keeping the exported token above it is sound.
Two things below. The first is the same silent-NotLoggedIn symptom this PR exists to remove, reachable by a different route.
Checked and clean: empty value, trailing slash, nonexistent directory, the container-side ${CLAUDE_CONFIG_DIR:-$HOME/.claude} agreeing on empty-is-unset, and no host env leaking past --send-env. I looked for a multi-path (colon-separated) assumption and found no evidence Claude Code accepts one, so I am not raising it.
One non-finding worth knowing: the e2e suite scopes HOME to a scratch dir (test/fixtures/e2e_helpers.py:410) but does not scrub CLAUDE_CONFIG_DIR, so on a developer machine that sets it, e2e runs now read the real credential. Nothing asserts token absence, so nothing breaks.
§4: the numbered 4-item order in the module note, HostEnv.config_dir's field doc, and docs/workspace-tools.md:83-87 all read as a fallback chain, which is exactly what the code refuses to be — each is corrected two lines later, so it is a clarity cost rather than a wrong claim.
Spec axis: no spec available (no Closes #n, no linked issue), so that axis was skipped.
| Self { | ||
| disable: crate::osext::env_str(DISABLE_VAR), | ||
| token: crate::osext::env_str(TOKEN_VAR), | ||
| config_dir: crate::osext::env_str(CONFIG_DIR_VAR), |
There was a problem hiding this comment.
osext::env_str is var_os(..).to_string_lossy().into_owned() (osext.rs:52-61), so a non-UTF-8 path is mangled rather than preserved. CLAUDE_CONFIG_DIR=/home/u/cfg-caf\xe9 (legal bytes on Linux) becomes /home/u/cfg-caf\u{FFFD}, read_to_string fails, and the result is Missing(NoToken::NotLoggedIn) — unwarned. That is precisely the "authenticated host reports itself as not logged in" symptom this PR removes, reintroduced for a path the variable can legally hold.
The docstring at :236 cites crate::domain::xdg as the precedent, and xdg::resolve deliberately takes Option<OsString> and does a byte-preserving PathBuf::from(value). This arm matches xdg on empty-is-unset but not on this.
Suggest HostEnv.config_dir: Option<OsString> filled from std::env::var_os; OsString satisfies the derives already on HostEnv.
There was a problem hiding this comment.
Fixed. `HostEnv.config_dir` is an `OsString` from `std::env::var_os`, matching `domain::xdg::resolve`. The new test asserts both halves: the directory is opened as named, and the lossy spelling of the same path really would have returned `NotLoggedIn`.
| reading, which is the same defect as ignoring the variable and harder to notice, | ||
| because it only shows itself on a host with two logins. So a `$CLAUDE_CONFIG_DIR` | ||
| that names a directory holding no credential is a host that is not logged in, and | ||
| the launch says so once rather than quietly forwarding the other account. |
There was a problem hiding this comment.
"the launch says so once" — nothing says so. forwarded_claude maps every TokenLookup::Missing(_) to None with no notice (flows/launch.rs:1938-1941), and enum LaunchNotice has NoGitHubToken but no Claude arm. So a $CLAUDE_CONFIG_DIR naming a directory with no credential forwards nothing and is silent, not announced.
This PR's own test comment says the opposite and is the accurate one: "its absence is the quiet arm and not a warning" (clients/claude.rs:441). Suggest deleting the clause, or saying the launch is deliberately silent here — the sentence as written promises a message a reader will go looking for.
There was a problem hiding this comment.
Fixed. The clause is gone; the page now says the launch is deliberately silent here and why (the macOS keychain case), which is what the branch's own test comment already said. The four-item list is two items now, for the same reason it was flagged.
… not there `osext::env_str` decodes lossily on purpose, which is right for the two switches either side of this field and wrong for this one: a path is bytes, not text. `CLAUDE_CONFIG_DIR=/home/u/cfg-caf<0xE9>` -- a legal Linux path -- arrived with U+FFFD where the byte was, failed to open, and reported `NotLoggedIn` on a host holding a good login. That is the exact symptom this branch exists to remove, so reading the variable back in lossily was the same defect wearing the fix's clothes. `HostEnv.config_dir` is an `OsString` filled from `std::env::var_os`, which is what `domain::xdg`'s `resolve` already does and the rule this arm already cited. The new test asserts both halves: the directory is opened as named, and the lossy spelling of the same path really would have missed it. Two documentation corrections in the same change, both about claims this branch added: - `docs/workspace-tools.md` said "the launch says so once rather than quietly forwarding the other account". Nothing says so: `forwarded_claude` maps every `TokenLookup::Missing(_)` to `None` and `LaunchNotice` has no Claude arm. The silence is deliberate -- on macOS the credential is in the login keychain and no file is the ordinary state -- so the page now says that instead of promising a message a reader would go looking for. The branch's own test comment already said the accurate thing. - The four-item list in the module note and on the page read as a fallback chain, which is precisely what the code refuses to be. The two file reads are now one item, since a reader who stops at the list should not come away with the model the next paragraph has to argue against.
Codecov Report❌ Patch coverage is
Additional details and impacted files
Flags with carried forward coverage won't be shown. Click here to find out more.
🚀 New features to boost your workflow:
|
A host that has moved its Claude configuration now forwards its login, instead of
reporting itself as not logged in.
Claude Code reads
$CLAUDE_CONFIG_DIRbefore~/.claude, and the probedlruns inside a container has always read it too, because a devcontainer feature
may set it. Only the host side did not: it joined a hardcoded
.claude/.credentials.jsononto$HOME, so a host that had moved itsconfiguration returned
NoToken::NotLoggedInwhile holding a perfectly goodlogin, and
claudein every workspace asked for a login.The symptom was silent, which is why it survived. "No credential file at
~/.claude" is also the ordinary macOS state, where the login lives in thekeychain, so it is deliberately not warned about. A host that had moved its config
got that same quiet answer, with nothing to tell the two apart.
The variable replaces the default rather than being tried ahead of it
Claude Code does not fall back from
$CLAUDE_CONFIG_DIRto~/.claude, and afallback here would forward a credential out of a directory Claude Code is not
reading. That is the same defect one level down: invisible until the host holds two
logins, at which point it forwards the wrong one. An empty value counts as unset,
matching
domain::xdg's rule and what a shell exporting a bare variable means.Order is unchanged above the new arm:
DEVLAUNCH_NO_CLAUDE_TOKENfirst, then anyexported
CLAUDE_CODE_OAUTH_TOKEN, and only then the file. The exported tokenstays above the variable because both are ambient, and that hatch is what lets a
dlrunning inside a workspace forward the token it was handed.One split worth naming
CREDENTIALS_RELPATHbecomesCONFIG_RELPATHplusCREDENTIALS_FILENAME, so thedirectory half is a decision and the filename half is a constant.
CONFIG_RELPATHis deliberately not shared withflows::provision'sCLAUDE_CONFIG_RELPATH: it is the same string about two different machines, and acomment in each names the other. Sharing it would couple the host's layout to the
container's, which is exactly the coupling this bug came from.
Scope
clients/claude.rsplus the README,docs/workspace-tools.mdand the changelog.Public-API snapshots are untouched and verified so, because every item involved is
pub(crate).Independent of #564.
feat: --claude-profile forwards a named Claude loginbuildson the
config_dir()this introduces and carries this commit until this merges;once it does, that PR's diff collapses to its own two commits.
🤖 Generated with Claude Code
https://claude.ai/code/session_01AdSFnBdxie6TosHVmjLY28
Summary by Sourcery
Honor
CLAUDE_CONFIG_DIRwhen forwarding host Claude logins so credential lookup matches Claude Code across relocated and non-UTF-8 configuration directories.Bug Fixes:
CLAUDE_CONFIG_DIRwhen locating host Claude credentials, preventing authenticated hosts with relocated configurations from being reported as logged out.Enhancements:
CLAUDE_CONFIG_DIRas a replacement for~/.claude, while preserving opt-out and exported-token precedence.Documentation:
CLAUDE_CONFIG_DIRenvironment variable in the README and workspace-tools guide.Tests: